#!/usr/bin/env python
# (c) Copyright 2009 Michael Z Goddard. All Rights Reserved. 
# This software is distributed under the MIT license.

import os
import time
import cgi
import mimetypes
import sys
import re
import sqlite3
import datetime
import socket
import optparse
import signal

if sys.version_info[:2] == (2,6):
    from io import BytesIO
    from collections import namedtuple
    import json
    import subprocess
else:
    from StringIO import StringIO as BytesIO
    from ntuple import namedtuple
    
    try:
        import simplejson as json
    except: 
        print "Warning: No json. Please install simplejson or python 2.6"

from wsgiref.simple_server import make_server

from jinja2 import Template
try:
    import pylab
except:
    print "Warning: No Charts. Please install matplotlib"

__docformat__ = "restructured"

# add .cur mimetype to make sending .cur files staticly possible
mimetypes.add_type("image/x-win-bitmap", ".cur")

Response = namedtuple("Response", "status headers lines")
Quicklog = namedtuple("Quicklog", "id name parent")
QuicklogNode = namedtuple("QuicklogNode", "name parent children")
LogStat = namedtuple("LogStat", "name count id")

mime = lambda f: mimetypes.guess_type(f)[0]
make_header = lambda m: [("content-type", m)]

_database = "quicklogs.db"

urls = (
    (r"^/?$", "", "log_page"),
    (r"^/log/?$", "", "log_page"),
    (r"^/log/(?P<log_type>[^/]+)/?$", "", "log_page"),
    (r"^/json/log/?(?P<log_type>[^/]+)?/?$", "", "log_json"),
    (r"^/xml/log/?(?P<log_type>[^/]+)?/?$", "", "log_xml"),
    (r"^/mimo/?$", "", "log_mimo"),
    (r"^.*static/(.*\.(?:jpg|html|js|css|cur))", "static/%%s", "static"),
    
    (r"^/stats/?(?:(?P<year>\d{4})/?)?(?:(?P<week>\d{0,2})wk/?)?(?:(?P<month>\d{0,2})m/?)?$", "", "stat_page"),
    (r"^/chart/?(?:(?P<year>\d{4})/?)?(?:(?P<week>\d{0,2})wk/?)?(?:(?P<month>\d{0,2})m/?)?$", "", "stat_chart"),
    (r"^/csv/?(?:(?P<year>\d{4})/?)?(?:(?P<week>\d{0,2})wk/?)?(?:(?P<month>\d{0,2})m/?)?$", "", "stat_csv"),
    (r"^/json/?(?:(?P<year>\d{4})/?)?(?:(?P<week>\d{0,2})wk/?)?(?:(?P<month>\d{0,2})m/?)?$", "", "stat_json"),
    (r"^/xml/?(?:(?P<year>\d{4})/?)?(?:(?P<week>\d{0,2})wk/?)?(?:(?P<month>\d{0,2})m/?)?$", "", "stat_xml"),
)
"""tuple of tuples containing regex and approproiate callback when POST_INFO
matches the regex.
"""

# compile the urls
urls = tuple([(re.compile(x[0]),) + x[1:] for x in urls])

url_dict = dict(((x[2], x) for x in urls))

def localpath(path):
    return os.path.join(os.path.dirname(__file__), path)

def main(environ, start_response):
    path_info = environ["PATH_INFO"][1:]
    
    # process forms
    form = environ["form"] = cgi.FieldStorage(fp=environ['wsgi.input'],
        environ=environ, keep_blank_values=1)
    
    for u in urls:
        m = u[0].match(environ["PATH_INFO"])
        
        if m:
            if m.groupdict():
                response = eval(u[2])(environ, **m.groupdict())
            else:
                response = eval(u[2])(environ, *m.groups())
            break
    else:
        response = Response("404 NOT FOUND", [], ["Invalid URL."])
    
    start_response(response.status, response.headers)
    return response.lines

def get_database(environ):
    """Open a connection to the sqlite3 database."""
    if "database" not in environ:
        run_create = not os.path.exists(localpath(_database))
        data = environ['database'] = sqlite3.connect(localpath(_database))
        if run_create:
            data.executescript(open(localpath("create.sql")).read())
            data.commit()
    return environ['database']

def add_log(environ, type, date=None):
    """Add a log to the database."""
    data = get_database(environ)
    data.execute('insert into logs (type) select name as type from log_types where name==?;', (type,))
    data.commit()

def add_type(environ, log_type, parent=None):
    """Add a log type to the database."""
    data = get_database(environ)
    data.execute('insert into log_types ("name", "parent") values(?, ?);', (log_type, parent))
    data.commit()

def log_types(environ):
    """Discover the types of logs currently used."""
    data = get_database(environ)
    types = data.execute('select "ROWID", "name", "parent" from log_types;')
    return [Quicklog(*x) for x in types]

def log_type_tree(environ):
    """Build a tree of the log types."""
    # TODO: Implement!
    nodes = []
    raise NotImplementedError()

def logs(environ, year=None, week=None, month=None):
    """Query the database and return a tuple of `LogStat` instances containing 
    the name of the log type and the count of how many times it appears within 
    the given timespan.
    
    :Returns:
      Tuple of `LogStat` instances.
    
    :Parameters:
      - environ: a mapping with assorted environment variables
      - year: either None or an int indicating the year to be searched
      - week: either None or an int indicating the week of the year to be 
        searched.  if -1 it will be set to the current week if year is None.
        ignored if month is not equal to None.
      - month: either None or an int indicating the month of the year to be 
        searched.  If -1 it will be set to the current month if year is None"""
    
    data = get_database(environ)
    
    if year != None:
        year = int(year)
    if week != None:
        week = int(week)
    if month != None:
        month = int(month)
    
    s = 'select name, count(type), log_types.ROWID from log_types left join logs on type==name'
    fill = tuple()
    
    # if year was unspecified but week was, use the current year
    thisyear = datetime.datetime.now().year
    if year == None and (week != None or month != None):
        year = thisyear
    
    # set the week to this week
    if week == -1 and year == thisyear:
        first_weekday = datetime.datetime(year, 01, 01).weekday()
        week = (datetime.datetime.now().timetuple().tm_yday + first_weekday) / 7
    elif week == -1:
        year = week = None
    
    if month == -1 and year == thisyear:
        today = datetime.datetime.now()
        month = today.month
    
    # create a start and finish time corresponding to year and week indicated
    if year:
        start = datetime.datetime(year, 01, 01)
        if week != None and month == None:
            start = start - datetime.timedelta(start.weekday() + 1)
            start += datetime.timedelta(week * 7)
            end = start + datetime.timedelta(6)
        elif month != None:
            start = datetime.date(year, month, 01)
            end = start + datetime.timedelta(31)
            if end.month != month:
                end -= datetime.timedelta(end.day)
        else:
            end = datetime.datetime(year, 12, 31)
        
        start = sqlite3.Date(*start.timetuple()[:3])
        end = sqlite3.Date(*end.timetuple()[:3])
        
        s += ' and "date">=? and "date"<=?'
        fill += (start, end)
    
    s += ' group by name;'
    
    l = [LogStat(*t) for t in data.execute(s, fill)]
    l.append(LogStat("total", sum([x.count for x in l]), -1))
    return l

def log_page(environ, log_type=None):
    """A page for entering new logs."""
    form = environ["form"]
    
    tolog = form.getfirst("log", log_type)
    if tolog:
        add_log(environ, tolog)
    
    environ["logs"] = logs(environ, week=-1)
    return render(environ, "logs.html")


def stat_page(environ, year=None, week=None, month=None):
    """A page for viewing stats of the log types determined by desired time 
    span."""
    
    thisyear = datetime.datetime.now().year
    
    environ["logs"] = logs(environ, year, week, month)
    
    # create a date range
    if year != None or week != None or month != None:
        if year:
            # year is set
            year = int(year)
            if week == None and month == None:
                # but week and month aren't
                datestr = str(year)
            elif month != None:
                # month is set
                month = int(month)
                
                start = datetime.datetime(year, month, 1)
                datestr = time.strftime("%B %Y", start.timetuple())
            elif week != None:
                # week is set
                week = int(week)
                
                # firstday = datetime.datetime(year)
                start = datetime.datetime(year, 1, 1)
                start -= datetime.timedelta(start.weekday() + 1)
                start += datetime.timedelta(7 * week)
                end = start + datetime.timedelta(6)
                datestr = time.strftime("%A, %B %d %Y", start.timetuple()) + \
                    " - " + \
                    time.strftime("%A, %B %d %Y", end.timetuple())
        elif month:
            # year not set
            month = int(month)
            
            datestr = time.strftime("%B %Y", datetime.date(thisyear, month, 1).timetuple())
        elif week:
            # year not set
            week = int(week)
            start = datetime.datetime(thisyear, 1, 1)
            start -= datetime.timedelta(start.weekday() + 1)
            start += datetime.timedelta(7 * week)
            end = start + datetime.timedelta(7)
            datestr = time.strftime("%A, %B %d %Y", start.timetuple()) + \
                " - " + \
                time.strftime("%A, %B %d %Y", end.timetuple())
    else:
        first = get_database(environ).execute("select date from logs order by date asc limit 1").fetchall()[0][0]
        first = time.strptime(first, "%Y-%m-%d")
        datestr = "Since " + time.strftime("%A, %B %d %Y", first)
    
    environ["datestring"] = datestr
    
    # build a link for a diagram of this time selection
    chart = "/chart/"
    if year: chart += "%4d/" % int(year)
    if week != None: chart += "%dwk/" % int(week)
    environ["chart"] = chart
    
    # build a link to a csv file for this time selection
    csv = "/csv/"
    if year: csv += "%4d/" % int(year)
    if week != None: csv += "%dwk/" % int(week)
    environ["csv"] = csv
    
    # build a link to a json file for this time selection
    json = "/json/"
    if year: json += "%4d/" % int(year)
    if week != None: json += "%2dwk/" % int (week)
    environ["json"] = json
    
    # build a link to the current week
    first_weekday = datetime.datetime(thisyear, 01, 01).weekday()
    this_week = (datetime.datetime.now().timetuple().tm_yday + first_weekday) / 7
    environ["this_week"] = "/stats/%4d/%2dwk/" % (thisyear, this_week)
    
    if week == None: week = this_week
    else: week = int(week)
    
    # next week
    next_week = week + 1
    environ["next_week"] = "/stats/%4d/%2dwk/" % (thisyear, next_week)
    
    # and the previous week
    prev_week = week - 1
    environ["prev_week"] = "/stats/%4d/%2dwk/" % (thisyear, prev_week)
    
    this_month = int(datetime.datetime.now().month)
    environ["this_month"] = "/stats/%4d/%02dm/" % (thisyear, this_month)
    
    return render(environ, "stat.html")

def stat_chart(environ, year=None, week=None, month=None):
    """A diagram representing the logs of the time selection in a image 
    format.
    """
    b = BytesIO()
    l = logs(environ, year, week, month)[:-1]
    
    # create the image
    pylab.figure(1, (3,3))
    pylab.pie([x.count for x in l])
    pylab.legend([x.name for x in l], loc='lower center', ncol=2)
    
    # save the image
    pylab.savefig(b, format="png")
    pylab.close()
    
    b.seek(0)
    return Response("200 OK", [("Content-Type", "image/png")], [b.read()])

def stat_csv(environ, year=None, week=None, month=None):
    """A csv formatted version of the data for the time selection."""
    l = logs(environ, year, week, month)
    
    # print ["%s, %d" % x[:2] for x in l]
    return Response(
        "200 OK",
        make_header("text/csv"),
        ["type, count\n"] + [str("%s, %d\n" % x[:2]) for x in l]
    )

def stat_json(environ, year=None, week=None, month=None):
    """A json formatted version of the data for the time selection."""
    l = logs(environ, year, week, month)
    
    return Response(
        "200 OK",
        make_header("text/json"),
        [json.dumps(l)]
    )

def stat_xml(environ, year=None, week=None, month=None):
    environ["logs"] = logs(environ, year, week, month)
    return render(environ, "logs.xml")


def log_json(environ, log_type=None):
    """Either returns a list of the log types and their current counts or a 
    list only containing the type incremented and by how many."""
    form = environ["form"]
    tolog = form.getfirst("log", log_type)
    if tolog:
        add_log(environ, tolog)
        
        return Response(
            "200 OK",
            make_header("text/json"),
            [json.dumps([(tolog, 1)])]
        )
    else:
        return Response(
            "200 OK",
            make_header("text/json"),
            [json.dumps(logs(environ, week=-1))]
        )
    

def log_xml(environ, log_type=None):
    form = environ["form"]
    tolog = form.getfirst("log", log_type)
    if tolog:
        add_log(environ, tolog)
        environ["logs"] = [LogStat(tolog, 1)]
        return render(environ, "logs.xml")
    else:
        environ["logs"] = logs(environ, week=-1)
        return render(environ, "logs.xml")

def log_mimo(environ):
    environ["logs"] = logs(environ, week=-1)
    return render(environ, "mimo.html")


def render(environ, f):
    """Render the template at the given file path with the given mapping as
    variables.
    """
    f = localpath(os.path.join("tmpl", f))
    status = "200 OK" if os.path.exists(f) else "404 NOT FOUND"
    rend = str(Template(open(f).read()).render(**environ)).splitlines()
    # print rend
    return Response(status, make_header(mime(f)), rend)

def static(environ, f):
    """Return a Response containing the static file."""
    f = localpath(os.path.join("resources", f))
    status = "200 OK" if os.path.exists(f) else "404 NOT FOUND"
    mime = mimetypes.guess_type(f)[0]
    if not os.path.exists(f): return Response(status, make_header("text/plain"), ["File not found."])
    if mime.startswith("text"):
        lines = open(f).readlines()
    else:
        lines = [open(f).read()]
    return Response(status, [("Content-type", mime)], lines)
    

def redirect(environ, dest):
    """Redirect to another page."""
    return Response("303 SEE OTHER", [("Location", dest)], [])

def redirect_proxy(environ, dest):
    """Redirect to another page with the POST data in contact."""
    return Response("305 PROXY", [("Location", dest)], [])


def daemon(port=7900):
    try:
        httpd = make_server('', port, main)
        print "listening to port %d" % port
        
        # store the procs pid
        open(localpath('pid'), 'w').write(str(os.getpid()))
        
        httpd.serve_forever()
    except KeyboardInterrupt:
        print "keyboard interrupt"
    except socket.error, e:
        print e
        # if e.errno == 48: print "port %d is already in use" % port
        # else: print e.strerror

application = main

if __name__ == '__main__':
    # check for http headers if they exist run as cgi
    if "User-Agent" in os.environ:
        wsgiref.handlers.CGIHandler().run(main)
        raise SystemExit
    
    parser = optparse.OptionParser()
    parser.add_option('-d', '--daemon', help="Launch the server in a seperate process.", action="store_true", default=False)
    parser.add_option('-p', '--port', default=7900, type="int")
    parser.add_option('-k', '--kill', default=False, action="store_true")
    
    options, args = parser.parse_args()
    
    if options.kill and os.path.exists(localpath('pid')):
        pid = int(open(localpath('pid')).read())
        
        os.kill(pid, signal.SIGKILL)
        os.remove(localpath('pid'))
        
        print "daemon stopped"
    elif options.kill:
        print "No daemon is currently running"
    
    if options.daemon:
        if options.kill: time.sleep(0.1)
        
        # change output and error streams destination
        os.close(0)
        os.dup2(os.open(localpath('access_logs'), os.O_WRONLY | os.O_APPEND | os.O_CREAT), 1)
        os.dup2(os.open(localpath('error_logs'), os.O_WRONLY | os.O_APPEND | os.O_CREAT), 2)
        
        if sys.version_info[:2] == (2,6):
            pid = subprocess.Popen([sys.argv[0], '--port', str(options.port)]).pid
        else:
            pid = os.spawnv(os.P_NOWAIT, sys.argv[0], [sys.argv[0], '--port', str(options.port)])
        
        time.sleep(1.0)
    elif not options.kill:
        daemon(options.port)
